Skip to content

Add Case / When conditional function - #1421

Open
regiscamimura wants to merge 3 commits into
piccolo-orm:masterfrom
regiscamimura:case-when
Open

regiscamimura wants to merge 3 commits into
piccolo-orm:masterfrom
regiscamimura:case-when

Conversation

@regiscamimura

@regiscamimura regiscamimura commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

piccolo.query.functions.conditional has Coalesce and NullIf, but there's no
way to express a SQL CASE statement. This adds Case and When, in the same
style as the existing functions.

from piccolo.query.functions import Case, When

await Band.select(
    Band.name,
    Case(
        When(Band.popularity > 900, then='super popular'),
        When(Band.popularity > 500, then='popular'),
        default='not popular',
        alias='popularity_label',
    ),
)

Because it's a QueryString, it also works in where clauses and order_by:

await Band.select(Band.name).order_by(
    Case(
        When(Band.name == 'Rustaceans', then=1),
        default=2,
    )
)

The one non-obvious bit: THEN values aren't always query parameters

Postgres can't infer the type of a bare parameter inside a CASE, so it assumes
text. If you bind an integer, asyncpg then rejects it before the query is even
sent:

CASE WHEN "band"."popularity" > $1 THEN $2 ELSE $3 END
asyncpg.exceptions.DataError: invalid input for query argument $2: 1 (expected str, got int)

Strings happen to work, which makes it an easy trap to fall into — the obvious
example works and the second one doesn't.

So get_case_value_string writes numbers, booleans and None into the SQL
directly, and parameterises everything else. There's no injection risk, as the
branch is only taken once the value is known to be a Python int / float /
Decimal / bool (non-finite floats and decimals fall back to a parameter).

Alternatives I considered, in case you'd prefer one of them:

  • Wrap the values in Cast automatically. Needs a Python-type → SQL-type
    mapping which Piccolo doesn't have today, and the guesses are lossy (is an
    int an INTEGER or a BIGINT?). It's also risky on SQLite, where
    CAST(? AS TIMESTAMP) would mangle a datetime.
  • Leave it to the user. Cast's own docstring already documents this class
    of problem, so there's precedent — but it makes the most obvious Case
    example fail, which felt like the wrong default.

Happy to change it either way.

Notes

  • default is optional. Omitting it omits the ELSE, which is the same thing
    as ELSE NULL in SQL.
  • alias defaults to "case", matching how Function derives its default
    alias — without it Postgres and SQLite name the column differently.
  • When accepts a where clause (Where / WhereRaw / And / Or) or a
    QueryString, and then / default accept a column, another function, or a
    plain value.

A condition may be a where clause or a QueryString, and only the first is
covered - the pass-through branch is what a function-wrapped predicate takes,
e.g. Upper(col).like(...).
@dantownsend

Copy link
Copy Markdown
Member

Good idea, thanks!

@dantownsend

Copy link
Copy Markdown
Member

I like the syntax of this. The only question is this bit THEN values aren't always query parameters.

It seems to fail at the moment on Cockroach because of IndeterminateDatatypeError.

@regiscamimura

Copy link
Copy Markdown
Contributor Author

Yeah, it seems Cockroach doesn't default an untyped placeholder to text the way Postgres does, so a bare $n inside CASE fails for strings too, not just numbers.

Rather than extend the literal-inlining workaround, I've dropped it: every plain Python value is now a parameter wrapped in a CAST (str→TEXT, int→BIGINT, float→DOUBLE PRECISION, Decimal→NUMERIC, bool→BOOLEAN), which is what Concat already does. No SQL literals, no type inference to worry about:

CASE WHEN "ticket"."price" >= $1 THEN CAST($2 AS TEXT) ELSE CAST($3 AS TEXT) END

Cockroach can't infer the type of a bare placeholder inside CASE and
raises IndeterminateDatatypeError, so string THEN/ELSE values failed
there. Wrap every plain Python value in a CAST keyed off its type
(str/int/float/Decimal/bool), the same as Concat does, and drop the
literal inlining for numbers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants